iT邦幫忙

2023 iThome 鐵人賽

DAY 23
1
影片教學

Nuxt 3 快速入門系列 第 23

[影片教學] Nuxt 3 建立第一個網站 - 實戰部落格系列 Part 1

  • 分享至 

  • xImage
  •  

Yes

👆建議你可以使用影片子母畫面功能或全螢幕播放來獲得最佳的觀賞體驗,👇下方是本篇教學的相關筆記。


建立預設布局模板與路由頁面

layouts 目錄下建立一個檔案 ./layouts/default.vue

<template>
  <div>
    <slot />
  </div>
</template>

pages 目錄下建立一個檔案 ./pages/index.vue

<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl font-semibold text-gray-700">
        這裡是
        <a href="https://ithelp.ithome.com.tw/users/20152617/ironman/6964" target="_blank">
          Nuxt 3 快速入門
        </a>
        實戰部落格
      </h1>
    </div>
  </div>
</template>

修改 app.vue 檔案:

 <template>
   <div>
    <NuxtPage />
   </div>
 </template>

導入 Nuxt Icon 模組

安裝 NuxtIcon 套件

npm install -D nuxt-icon

修改專案目錄下 nuxt.config.ts 檔案,添加使用 nuxt-iconmodules 選項

export default defineNuxtConfig({
  modules: ['nuxt-icon']
})

建立導覽列元件

components 目錄下建立一個檔案 ./components/LayoutHeader.vue

<template>
  <header class="flex w-full justify-center">
    <nav class="flex w-full max-w-7xl items-center justify-between px-6 py-2">
      <div>
        <a href="/">
          <div class="flex items-center justify-between">
            <div class="mr-3">
              <Icon class="h-12 w-12" name="logos:nuxt-icon" />
            </div>
            <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">Nuxt 3 Blog</div>
          </div>
        </a>
      </div>

      <NuxtLink
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </nav>
  </header>
</template>

調整預設布局模板 ./pages/index.vue 檔案,添加使用 元件:

<template>
  <div>
    <LayoutHeader />
    <slot />
  </div>
</template>

建立登入頁面

pages 目錄下建立一個檔案 ./pages/login.vue

<template>
  <div class="flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <NuxtLink to="/">
          <Icon name="logos:nuxt-icon" size="80" />
        </NuxtLink>
        <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-700">登入帳號</h2>
      </div>

      <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
          <form class="space-y-6">
            <div>
              <label for="account" class="block text-sm font-medium text-gray-700">帳號</label>
              <div class="mt-1">
                <input
                  id="account"
                  name="account"
                  type="text"
                  autocomplete="account"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>

            <div>
              <label for="password" class="block text-sm font-medium text-gray-700">密碼</label>
              <div class="mt-1">
                <input
                  id="password"
                  name="password"
                  type="password"
                  autocomplete="current-password"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>

            <div>
              <button
                type="submit"
                class="flex w-full justify-center rounded-md border border-transparent bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
              >
                登入
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

調整預設登入頁面 ./pages/login.vue 檔案,添加 definePageMeta 函式來禁止使用布局模板:

<script setup>
definePageMeta({
  layout: false
})
</script>

建立登入 API 與產生 JWT Token

安裝 jsonwebtoken 套件

npm install -D jsonwebtoken

server/api 目錄下建立一個檔案 ./server/api/login.post.js

import jwt from 'jsonwebtoken'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  if (!(body.account === 'ryan' && body.password === 'iThome2023')) {
    throw createError({
      statusCode: 400,
      statusMessage: '登入失敗'
    })
  }

  const jwtTokenPayload = {
    id: 1,
    nickname: 'Ryan',
    email: 'ryanchien8125@gmail.com',
    avatar: 'https://images.unsplash.com/photo-1577023311546-cdc07a8454d9?fit=crop&w=128&h=128'
  }

  const maxAge = 60 * 60 * 24 * 7
  const expires = Math.floor(Date.now() / 1000) + maxAge

  const jwtToken = jwt.sign(
    {
      exp: expires,
      data: jwtTokenPayload
    },
    'JWT_SIGN_SECRET_PLEASE_REPLACE_WITH_YOUR_KEY'
  )

  setCookie(event, 'access_token', jwtToken, {
    maxAge,
    expires: new Date(expires * 1000),
    secure: true,
    httpOnly: true,
    path: '/'
  })

  return '登入成功'
})

調整登入頁面串接登入 API

調整登入頁面 ./pages/login.vue 檔案,添加建立 loginData 響應式物件並綁定至表單的 input 中:

const loginData = reactive({
  account: '',
  password: ''
})
<template>
  <form>
    <input
      v-model="loginData.account"
      name="account"
      type="text"
      ...
   />
   <input
      v-model="loginData.password"
      name="password"
      type="text"
      ...
   />
  </form>
</template>

建立登入函式,將帳號及密碼字串夾帶發送至登入 API

const handleLogin = async () => {
  const { data } = await useFetch('/api/login', {
    method: 'POST',
    body: {
      account: loginData.account,
      password: loginData.password
    }
  })

  if (data.value) {
    navigateTo('/')
  }
}

添加 handleLogin 至登入表單內 submit 事件,並添加 .prevent 修飾符。

<template>
  <form @submit.prevent="handleLogin">
    ...
  </form>
</template>

最後,串接完成的 login.vue 檔案,看起來會像這樣:

  <div class="flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <NuxtLink to="/">
          <Icon name="logos:nuxt-icon" size="80" />
        </NuxtLink>
        <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-700">登入帳號</h2>
      </div>

      <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <div class="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
          <form class="space-y-6" @submit.prevent="handleLogin">
            <div>
              <label for="account" class="block text-sm font-medium text-gray-700">帳號</label>
              <div class="mt-1">
                <input
                  id="account"
                  v-model="loginData.account"
                  name="account"
                  type="text"
                  autocomplete="account"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>

            <div>
              <label for="password" class="block text-sm font-medium text-gray-700">密碼</label>
              <div class="mt-1">
                <input
                  id="password"
                  v-model="loginData.password"
                  name="password"
                  type="password"
                  autocomplete="current-password"
                  required
                  class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
                />
              </div>
            </div>

            <div>
              <button
                type="submit"
                class="flex w-full justify-center rounded-md border border-transparent bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
              >
                登入
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
definePageMeta({
  layout: false
})

const loginData = reactive({
  account: '',
  password: ''
})

const handleLogin = async () => {
  const { data } = await useFetch('/api/login', {
    method: 'POST',
    body: {
      account: loginData.account,
      password: loginData.password
    }
  })

  if (data.value) {
    navigateTo('/')
  }
}
</script>

建立查詢使用者資訊的 API

server/api 目錄下建立一個檔案 ./server/api/whoami.get.js

export default defineEventHandler((event) => {
  setCookie(event, 'access_token', null)

  return 'ok'
})

建立使用者頭像選單

調整 ./components/LayoutHeader.vue 檔案,呼叫使用 whoami API 取得使用者資訊

<script setup>
const { data: userInfo } = await useFetch('/api/whoami')
</script>

調整 ./components/LayoutHeader.vue 檔案,建立使用者頭像選單,並呈現 userInfo 的資訊

<div class="group relative">
  <label for="avatar" class="cursor-pointer">
    <img
      class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
      :src="userInfo.avatar"
      alt="使用者選單"
    />
  </label>
  <div class="absolute right-0 hidden w-60 pt-1 text-gray-700 group-hover:block">
    <div
      class="mt-1 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
    >
      <div class="flex items-center px-4 py-3">
        <img
          :src="userInfo.avatar"
          class="inline-block h-9 w-9 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur"
        />
        <div class="ml-3.5 flex-grow overflow-hidden">
          <p class="overflow-hidden text-ellipsis font-medium">{{ userInfo.nickname }}</p>
          <p class="overflow-hidden text-ellipsis text-xs text-gray-500">
            {{ userInfo.email }}
          </p>
        </div>
      </div>
      <div class="group/menu-item px-1 py-1">
        <NuxtLink
          class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
        >
          <Icon
            class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
            name="ri:pencil-line"
          />
          撰寫文章
        </NuxtLink>
      </div>
      <div class="group/menu-item px-1 py-1">
        <button
          class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
        >
          <Icon
            class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
            name="ri:logout-box-line"
          />
          登出
        </button>
      </div>
    </div>
  </div>
</div>

建立登出註銷 Session 的 API

server/api 目錄下建立一個檔案 ./server/api/session.post.js

export default defineEventHandler((event) => {
  setCookie(event, 'access_token', null)

  return 'ok'
})

串接登出 API

調整 ./components/LayoutHeader.vue 檔案,建立 handleLogout 函式用於註銷 Session

const handleLogout = () => {
  $fetch('/api/session', {
    method: 'DELETE'
  }).then(() => {
    userInfo.value = null
  })
}

添加 handleLogout 函式至使用者選單的登出按鈕 click 事件

<button
  class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
  @click="handleLogout"
>
  <Icon
    class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
    name="ri:logout-box-line"
  />
  登出
</button>

使用者頭像選單的顯示判斷

調整 ./components/LayoutHeader.vue 檔案,使用 v-if 指令判斷 userInfo 狀態來顯示使用者選單或登入連結

<template>
  <header class="flex w-full justify-center">
    <nav class="flex w-full max-w-7xl items-center justify-between px-6 py-2">
      <div>
        <a href="/">
          <div class="flex items-center justify-between">
            <div class="mr-3">
              <Icon class="h-12 w-12" name="logos:nuxt-icon" />
            </div>
            <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">Nuxt 3 Blog</div>
          </div>
        </a>
      </div>

      <div v-if="userInfo" class="group relative">
        <label for="avatar" class="cursor-pointer">
          <img
            class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
            :src="userInfo.avatar"
            alt="使用者選單"
          />
        </label>
        <div class="absolute right-0 hidden w-60 pt-1 text-gray-700 group-hover:block">
          <div
            class="mt-1 divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
          >
            <div class="flex items-center px-4 py-3">
              <img
                :src="userInfo.avatar"
                class="inline-block h-9 w-9 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur"
              />
              <div class="ml-3.5 flex-grow overflow-hidden">
                <p class="overflow-hidden text-ellipsis font-medium">{{ userInfo.nickname }}</p>
                <p class="overflow-hidden text-ellipsis text-xs text-gray-500">
                  {{ userInfo.email }}
                </p>
              </div>
            </div>
            <div class="group/menu-item px-1 py-1">
              <NuxtLink
                class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
              >
                <Icon
                  class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
                  name="ri:pencil-line"
                />
                撰寫文章
              </NuxtLink>
            </div>
            <div class="group/menu-item px-1 py-1">
              <button
                class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
                @click="handleLogout"
              >
                <Icon
                  class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
                  name="ri:logout-box-line"
                />
                登出
              </button>
            </div>
          </div>
        </div>
      </div>
      <NuxtLink
        v-else
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </nav>
  </header>
</template>

<script setup>
const { data: userInfo } = await useFetch('/api/whoami')

const handleLogout = () => {
  $fetch('/api/session', {
    method: 'DELETE'
  }).then(() => {
    userInfo.value = null
  })
}
</script>

添加使用者頭像選單至預設佈局模板

調整 ./layouts/default.vue 檔案,添加自訂元件 至預設插槽上方

<template>
  <div>
    <LayoutHeader />
    <slot />
  </div>
</template>

感謝大家的閱讀,歡迎大家給予建議與討論,也請各位大大鞭小力一些:)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。

範例程式碼


上一篇
[影片教學] Nuxt 3 靜態資源的管理 - Public & Assets
下一篇
[影片教學] Nuxt 3 建立部落格文章相關 API - 實戰部落格系列 Part 2
系列文
Nuxt 3 快速入門30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言